Java 26-Day Course - Day 25: REST API Implementation

Day 25: REST API Implementation

REST (Representational State Transfer) API is the standard way to exchange data on the web. It manipulates resources using HTTP methods (GET, POST, PUT, DELETE). Today we build a layered, production-grade REST API with Spring Boot.

Project Layer Structure

Design with a Controller - Service - Repository 3-layer architecture.

// Project structure:
// src/main/java/com/example/todoapi/
// ├── TodoApiApplication.java       (main)
// ├── controller/
// │   └── TodoController.java       (HTTP request handling)
// ├── service/
// │   └── TodoService.java          (business logic)
// ├── repository/
// │   └── TodoRepository.java       (data access)
// ├── dto/
// │   ├── TodoRequest.java          (request DTO)
// │   └── TodoResponse.java         (response DTO)
// ├── domain/
// │   └── Todo.java                 (domain entity)
// └── exception/
//     └── GlobalExceptionHandler.java (global exception handling)

// Domain entity
public class Todo {
    private Long id;
    private String title;
    private String description;
    private boolean completed;
    private String priority; // HIGH, MEDIUM, LOW
    private java.time.LocalDateTime createdAt;
    private java.time.LocalDateTime updatedAt;

    // Constructor, getters, setters, etc.
    public Todo(Long id, String title, String description, String priority) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.priority = priority;
        this.completed = false;
        this.createdAt = java.time.LocalDateTime.now();
        this.updatedAt = this.createdAt;
    }

    // getters/setters omitted (required in practice)
}

DTOs and Request/Response Separation

Define the data formats exchanged with clients.

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.time.LocalDateTime;

// Request DTO (client -> server)
record TodoRequest(
    @NotBlank(message = "Title is required")
    @Size(max = 100, message = "Title must be 100 characters or less")
    String title,

    @Size(max = 500, message = "Description must be 500 characters or less")
    String description,

    String priority  // HIGH, MEDIUM, LOW
) {
    // Default value setup
    public TodoRequest {
        if (priority == null || priority.isBlank()) {
            priority = "MEDIUM";
        }
    }
}

// Response DTO (server -> client)
record TodoResponse(
    Long id,
    String title,
    String description,
    boolean completed,
    String priority,
    LocalDateTime createdAt,
    LocalDateTime updatedAt
) {
    // Entity -> Response DTO conversion
    static TodoResponse from(Todo todo) {
        return new TodoResponse(
            todo.getId(), todo.getTitle(), todo.getDescription(),
            todo.isCompleted(), todo.getPriority(),
            todo.getCreatedAt(), todo.getUpdatedAt()
        );
    }
}

// Error response DTO
record ErrorResponse(
    int status,
    String message,
    String detail,
    LocalDateTime timestamp
) {
    static ErrorResponse of(int status, String message, String detail) {
        return new ErrorResponse(status, message, detail, LocalDateTime.now());
    }
}

// Pagination response
record PageResponse<T>(
    java.util.List<T> content,
    int page,
    int size,
    long totalElements,
    int totalPages
) {}

Service and Repository Layers

Separate business logic and data access.

import org.springframework.stereotype.Repository;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

// Repository: data store (in-memory)
@Repository
class TodoRepository {
    private final Map<Long, Todo> store = new ConcurrentHashMap<>();
    private final AtomicLong idGenerator = new AtomicLong(1);

    Todo save(Todo todo) {
        if (todo.getId() == null) {
            todo.setId(idGenerator.getAndIncrement());
        }
        store.put(todo.getId(), todo);
        return todo;
    }

    Optional<Todo> findById(Long id) {
        return Optional.ofNullable(store.get(id));
    }

    List<Todo> findAll() {
        return new ArrayList<>(store.values());
    }

    void deleteById(Long id) {
        store.remove(id);
    }

    boolean existsById(Long id) {
        return store.containsKey(id);
    }
}

// Service: business logic
@Service
class TodoService {
    private final TodoRepository repository;

    TodoService(TodoRepository repository) {
        this.repository = repository;
    }

    TodoResponse create(TodoRequest request) {
        Todo todo = new Todo(null, request.title(),
                             request.description(), request.priority());
        Todo saved = repository.save(todo);
        return TodoResponse.from(saved);
    }

    TodoResponse findById(Long id) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException("Todo not found: " + id));
        return TodoResponse.from(todo);
    }

    List<TodoResponse> findAll(String priority, Boolean completed) {
        return repository.findAll().stream()
            .filter(t -> priority == null || t.getPriority().equals(priority))
            .filter(t -> completed == null || t.isCompleted() == completed)
            .map(TodoResponse::from)
            .toList();
    }

    TodoResponse update(Long id, TodoRequest request) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException("Todo not found: " + id));
        todo.setTitle(request.title());
        todo.setDescription(request.description());
        todo.setPriority(request.priority());
        todo.setUpdatedAt(java.time.LocalDateTime.now());
        repository.save(todo);
        return TodoResponse.from(todo);
    }

    TodoResponse toggleComplete(Long id) {
        Todo todo = repository.findById(id)
            .orElseThrow(() -> new TodoNotFoundException("Todo not found: " + id));
        todo.setCompleted(!todo.isCompleted());
        todo.setUpdatedAt(java.time.LocalDateTime.now());
        repository.save(todo);
        return TodoResponse.from(todo);
    }

    void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new TodoNotFoundException("Todo not found: " + id);
        }
        repository.deleteById(id);
    }
}

// Custom exception
class TodoNotFoundException extends RuntimeException {
    TodoNotFoundException(String message) { super(message); }
}

Controller and Global Exception Handling

Implement REST endpoints and error handling.

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.MethodArgumentNotValidException;
import jakarta.validation.Valid;
import java.util.List;

@RestController
@RequestMapping("/api/todos")
class TodoController {
    private final TodoService todoService;

    TodoController(TodoService todoService) {
        this.todoService = todoService;
    }

    // POST /api/todos
    @PostMapping
    ResponseEntity<TodoResponse> create(@Valid @RequestBody TodoRequest request) {
        TodoResponse response = todoService.create(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    // GET /api/todos
    @GetMapping
    List<TodoResponse> findAll(
            @RequestParam(required = false) String priority,
            @RequestParam(required = false) Boolean completed) {
        return todoService.findAll(priority, completed);
    }

    // GET /api/todos/{id}
    @GetMapping("/{id}")
    TodoResponse findById(@PathVariable Long id) {
        return todoService.findById(id);
    }

    // PUT /api/todos/{id}
    @PutMapping("/{id}")
    TodoResponse update(@PathVariable Long id,
                        @Valid @RequestBody TodoRequest request) {
        return todoService.update(id, request);
    }

    // PATCH /api/todos/{id}/toggle
    @PatchMapping("/{id}/toggle")
    TodoResponse toggleComplete(@PathVariable Long id) {
        return todoService.toggleComplete(id);
    }

    // DELETE /api/todos/{id}
    @DeleteMapping("/{id}")
    ResponseEntity<Void> delete(@PathVariable Long id) {
        todoService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

// Global exception handling
@RestControllerAdvice
class GlobalExceptionHandler {

    @ExceptionHandler(TodoNotFoundException.class)
    ResponseEntity<ErrorResponse> handleNotFound(TodoNotFoundException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND)
            .body(ErrorResponse.of(404, "Not Found", e.getMessage()));
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
        String detail = e.getBindingResult().getFieldErrors().stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .reduce((a, b) -> a + "; " + b)
            .orElse("Validation failed");
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse.of(400, "Validation Error", detail));
    }

    @ExceptionHandler(Exception.class)
    ResponseEntity<ErrorResponse> handleGeneral(Exception e) {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(500, "Internal Server Error", e.getMessage()));
    }
}

Today’s Exercises

  1. Search and Pagination: Add title search (?keyword=xxx) and pagination (?page=1&size=10) features to the todo list. Use the PageResponse DTO.

  2. API Documentation: Write an API specification that includes request/response examples for each endpoint. Also create a script to test each API with curl commands.

  3. Integration Tests: Write tests for all TodoController endpoints using @SpringBootTest and MockMvc. Include both success cases and error cases (404, 400).

Was this article helpful?